import binarySearch from "../../Core/binarySearch.js";
import ClockRange from "../../Core/ClockRange.js";
import ClockStep from "../../Core/ClockStep.js";
import defined from "../../Core/defined.js";
import DeveloperError from "../../Core/DeveloperError.js";
import JulianDate from "../../Core/JulianDate.js";
import knockout from "../../ThirdParty/knockout.js";
import createCommand from "../createCommand.js";
import ToggleButtonViewModel from "../ToggleButtonViewModel.js";

const monthNames = [
  "Jan",
  "Feb",
  "Mar",
  "Apr",
  "May",
  "Jun",
  "Jul",
  "Aug",
  "Sep",
  "Oct",
  "Nov",
  "Dec",
];
const realtimeShuttleRingAngle = 15;
const maxShuttleRingAngle = 105;

function numberComparator(left, right) {
  return left - right;
}

function getTypicalMultiplierIndex(multiplier, shuttleRingTicks) {
  const index = binarySearch(shuttleRingTicks, multiplier, numberComparator);
  return index < 0 ? ~index : index;
}

function angleToMultiplier(angle, shuttleRingTicks) {
  //Use a linear scale for -1 to 1 between -15 < angle < 15 degrees
  if (Math.abs(angle) <= realtimeShuttleRingAngle) {
    return angle / realtimeShuttleRingAngle;
  }

  const minp = realtimeShuttleRingAngle;
  const maxp = maxShuttleRingAngle;
  let maxv;
  const minv = 0;
  let scale;
  if (angle > 0) {
    maxv = Math.log(shuttleRingTicks[shuttleRingTicks.length - 1]);
    scale = (maxv - minv) / (maxp - minp);
    return Math.exp(minv + scale * (angle - minp));
  }

  maxv = Math.log(-shuttleRingTicks[0]);
  scale = (maxv - minv) / (maxp - minp);
  return -Math.exp(minv + scale * (Math.abs(angle) - minp));
}

function multiplierToAngle(multiplier, shuttleRingTicks, clockViewModel) {
  if (clockViewModel.clockStep === ClockStep.SYSTEM_CLOCK) {
    return realtimeShuttleRingAngle;
  }

  if (Math.abs(multiplier) <= 1) {
    return multiplier * realtimeShuttleRingAngle;
  }

  const fastedMultipler = shuttleRingTicks[shuttleRingTicks.length - 1];
  if (multiplier > fastedMultipler) {
    multiplier = fastedMultipler;
  } else if (multiplier < -fastedMultipler) {
    multiplier = -fastedMultipler;
  }

  const minp = realtimeShuttleRingAngle;
  const maxp = maxShuttleRingAngle;
  let maxv;
  const minv = 0;
  let scale;

  if (multiplier > 0) {
    maxv = Math.log(fastedMultipler);
    scale = (maxv - minv) / (maxp - minp);
    return (Math.log(multiplier) - minv) / scale + minp;
  }

  maxv = Math.log(-shuttleRingTicks[0]);
  scale = (maxv - minv) / (maxp - minp);
  return -((Math.log(Math.abs(multiplier)) - minv) / scale + minp);
}

/**
 * The view model for the {@link Animation} widget.
 * @alias AnimationViewModel
 * @constructor
 *
 * @param {ClockViewModel} clockViewModel The ClockViewModel instance to use.
 *
 * @see Animation
 */
function AnimationViewModel(clockViewModel) {
  //>>includeStart('debug', pragmas.debug);
  if (!defined(clockViewModel)) {
    throw new DeveloperError("clockViewModel is required.");
  }
  //>>includeEnd('debug');

  const that = this;
  this._clockViewModel = clockViewModel;
  this._allShuttleRingTicks = [];
  this._dateFormatter = AnimationViewModel.defaultDateFormatter;
  this._timeFormatter = AnimationViewModel.defaultTimeFormatter;

  /**
   * Gets or sets whether the shuttle ring is currently being dragged.  This property is observable.
   * @type {Boolean}
   * @default false
   */
  this.shuttleRingDragging = false;

  /**
   * Gets or sets whether dragging the shuttle ring should cause the multiplier
   * to snap to the defined tick values rather than interpolating between them.
   * This property is observable.
   * @type {Boolean}
   * @default false
   */
  this.snapToTicks = false;

  knockout.track(this, [
    "_allShuttleRingTicks",
    "_dateFormatter",
    "_timeFormatter",
    "shuttleRingDragging",
    "snapToTicks",
  ]);

  this._sortedFilteredPositiveTicks = [];

  this.setShuttleRingTicks(AnimationViewModel.defaultTicks);

  /**
   * Gets the string representation of the current time.  This property is observable.
   * @type {String}
   */
  this.timeLabel = undefined;
  knockout.defineProperty(this, "timeLabel", function () {
    return that._timeFormatter(that._clockViewModel.currentTime, that);
  });

  /**
   * Gets the string representation of the current date.  This property is observable.
   * @type {String}
   */
  this.dateLabel = undefined;
  knockout.defineProperty(this, "dateLabel", function () {
    return that._dateFormatter(that._clockViewModel.currentTime, that);
  });

  /**
   * Gets the string representation of the current multiplier.  This property is observable.
   * @type {String}
   */
  this.multiplierLabel = undefined;
  knockout.defineProperty(this, "multiplierLabel", function () {
    const clockViewModel = that._clockViewModel;
    if (clockViewModel.clockStep === ClockStep.SYSTEM_CLOCK) {
      return "Today";
    }

    const multiplier = clockViewModel.multiplier;

    //If it's a whole number, just return it.
    if (multiplier % 1 === 0) {
      return `${multiplier.toFixed(0)}x`;
    }

    //Convert to decimal string and remove any trailing zeroes
    return `${multiplier.toFixed(3).replace(/0{0,3}$/, "")}x`;
  });

  /**
   * Gets or sets the current shuttle ring angle.  This property is observable.
   * @type {Number}
   */
  this.shuttleRingAngle = undefined;
  knockout.defineProperty(this, "shuttleRingAngle", {
    get: function () {
      return multiplierToAngle(
        clockViewModel.multiplier,
        that._allShuttleRingTicks,
        clockViewModel
      );
    },
    set: function (angle) {
      angle = Math.max(
        Math.min(angle, maxShuttleRingAngle),
        -maxShuttleRingAngle
      );
      const ticks = that._allShuttleRingTicks;

      const clockViewModel = that._clockViewModel;
      clockViewModel.clockStep = ClockStep.SYSTEM_CLOCK_MULTIPLIER;

      //If we are at the max angle, simply return the max value in either direction.
      if (Math.abs(angle) === maxShuttleRingAngle) {
        clockViewModel.multiplier =
          angle > 0 ? ticks[ticks.length - 1] : ticks[0];
        return;
      }

      let multiplier = angleToMultiplier(angle, ticks);
      if (that.snapToTicks) {
        multiplier = ticks[getTypicalMultiplierIndex(multiplier, ticks)];
      } else if (multiplier !== 0) {
        const positiveMultiplier = Math.abs(multiplier);

        if (positiveMultiplier > 100) {
          const numDigits = positiveMultiplier.toFixed(0).length - 2;
          const divisor = Math.pow(10, numDigits);
          multiplier = (Math.round(multiplier / divisor) * divisor) | 0;
        } else if (positiveMultiplier > realtimeShuttleRingAngle) {
          multiplier = Math.round(multiplier);
        } else if (positiveMultiplier > 1) {
          multiplier = +multiplier.toFixed(1);
        } else if (positiveMultiplier > 0) {
          multiplier = +multiplier.toFixed(2);
        }
      }
      clockViewModel.multiplier = multiplier;
    },
  });

  this._canAnimate = undefined;
  knockout.defineProperty(this, "_canAnimate", function () {
    const clockViewModel = that._clockViewModel;
    const clockRange = clockViewModel.clockRange;

    if (that.shuttleRingDragging || clockRange === ClockRange.UNBOUNDED) {
      return true;
    }

    const multiplier = clockViewModel.multiplier;
    const currentTime = clockViewModel.currentTime;
    const startTime = clockViewModel.startTime;

    let result = false;
    if (clockRange === ClockRange.LOOP_STOP) {
      result =
        JulianDate.greaterThan(currentTime, startTime) ||
        (currentTime.equals(startTime) && multiplier > 0);
    } else {
      const stopTime = clockViewModel.stopTime;
      result =
        (JulianDate.greaterThan(currentTime, startTime) &&
          JulianDate.lessThan(currentTime, stopTime)) || //
        (currentTime.equals(startTime) && multiplier > 0) || //
        (currentTime.equals(stopTime) && multiplier < 0);
    }

    if (!result) {
      clockViewModel.shouldAnimate = false;
    }
    return result;
  });

  this._isSystemTimeAvailable = undefined;
  knockout.defineProperty(this, "_isSystemTimeAvailable", function () {
    const clockViewModel = that._clockViewModel;
    const clockRange = clockViewModel.clockRange;
    if (clockRange === ClockRange.UNBOUNDED) {
      return true;
    }

    const systemTime = clockViewModel.systemTime;
    return (
      JulianDate.greaterThanOrEquals(systemTime, clockViewModel.startTime) &&
      JulianDate.lessThanOrEquals(systemTime, clockViewModel.stopTime)
    );
  });

  this._isAnimating = undefined;
  knockout.defineProperty(this, "_isAnimating", function () {
    return (
      that._clockViewModel.shouldAnimate &&
      (that._canAnimate || that.shuttleRingDragging)
    );
  });

  const pauseCommand = createCommand(function () {
    const clockViewModel = that._clockViewModel;
    if (clockViewModel.shouldAnimate) {
      clockViewModel.shouldAnimate = false;
    } else if (that._canAnimate) {
      clockViewModel.shouldAnimate = true;
    }
  });

  this._pauseViewModel = new ToggleButtonViewModel(pauseCommand, {
    toggled: knockout.computed(function () {
      return !that._isAnimating;
    }),
    tooltip: "Pause",
  });

  const playReverseCommand = createCommand(function () {
    const clockViewModel = that._clockViewModel;
    const multiplier = clockViewModel.multiplier;
    if (multiplier > 0) {
      clockViewModel.multiplier = -multiplier;
    }
    clockViewModel.shouldAnimate = true;
  });

  this._playReverseViewModel = new ToggleButtonViewModel(playReverseCommand, {
    toggled: knockout.computed(function () {
      return that._isAnimating && clockViewModel.multiplier < 0;
    }),
    tooltip: "Play Reverse",
  });

  const playForwardCommand = createCommand(function () {
    const clockViewModel = that._clockViewModel;
    const multiplier = clockViewModel.multiplier;
    if (multiplier < 0) {
      clockViewModel.multiplier = -multiplier;
    }
    clockViewModel.shouldAnimate = true;
  });

  this._playForwardViewModel = new ToggleButtonViewModel(playForwardCommand, {
    toggled: knockout.computed(function () {
      return (
        that._isAnimating &&
        clockViewModel.multiplier > 0 &&
        clockViewModel.clockStep !== ClockStep.SYSTEM_CLOCK
      );
    }),
    tooltip: "Play Forward",
  });

  const playRealtimeCommand = createCommand(function () {
    that._clockViewModel.clockStep = ClockStep.SYSTEM_CLOCK;
  }, knockout.getObservable(this, "_isSystemTimeAvailable"));

  this._playRealtimeViewModel = new ToggleButtonViewModel(playRealtimeCommand, {
    toggled: knockout.computed(function () {
      return clockViewModel.clockStep === ClockStep.SYSTEM_CLOCK;
    }),
    tooltip: knockout.computed(function () {
      return that._isSystemTimeAvailable
        ? "Today (real-time)"
        : "Current time not in range";
    }),
  });

  this._slower = createCommand(function () {
    const clockViewModel = that._clockViewModel;
    const shuttleRingTicks = that._allShuttleRingTicks;
    const multiplier = clockViewModel.multiplier;
    const index = getTypicalMultiplierIndex(multiplier, shuttleRingTicks) - 1;
    if (index >= 0) {
      clockViewModel.multiplier = shuttleRingTicks[index];
    }
  });

  this._faster = createCommand(function () {
    const clockViewModel = that._clockViewModel;
    const shuttleRingTicks = that._allShuttleRingTicks;
    const multiplier = clockViewModel.multiplier;
    const index = getTypicalMultiplierIndex(multiplier, shuttleRingTicks) + 1;
    if (index < shuttleRingTicks.length) {
      clockViewModel.multiplier = shuttleRingTicks[index];
    }
  });
}

/**
 * Gets or sets the default date formatter used by new instances.
 *
 * @member
 * @type {AnimationViewModel.DateFormatter}
 */
AnimationViewModel.defaultDateFormatter = function (date, viewModel) {
  const gregorianDate = JulianDate.toGregorianDate(date);
  return `${monthNames[gregorianDate.month - 1]} ${gregorianDate.day} ${
    gregorianDate.year
  }`;
};

/**
 * Gets or sets the default array of known clock multipliers associated with new instances of the shuttle ring.
 * @type {Number[]}
 */
AnimationViewModel.defaultTicks = [
  //
  0.001,
  0.002,
  0.005,
  0.01,
  0.02,
  0.05,
  0.1,
  0.25,
  0.5,
  1.0,
  2.0,
  5.0,
  10.0, //
  15.0,
  30.0,
  60.0,
  120.0,
  300.0,
  600.0,
  900.0,
  1800.0,
  3600.0,
  7200.0,
  14400.0, //
  21600.0,
  43200.0,
  86400.0,
  172800.0,
  345600.0,
  604800.0,
];

/**
 * Gets or sets the default time formatter used by new instances.
 *
 * @member
 * @type {AnimationViewModel.TimeFormatter}
 */
AnimationViewModel.defaultTimeFormatter = function (date, viewModel) {
  const gregorianDate = JulianDate.toGregorianDate(date);
  const millisecond = Math.round(gregorianDate.millisecond);
  if (Math.abs(viewModel._clockViewModel.multiplier) < 1) {
    return `${gregorianDate.hour
      .toString()
      .padStart(2, "0")}:${gregorianDate.minute
      .toString()
      .padStart(2, "0")}:${gregorianDate.second
      .toString()
      .padStart(2, "0")}.${millisecond.toString().padStart(3, "0")}`;
  }
  return `${gregorianDate.hour
    .toString()
    .padStart(2, "0")}:${gregorianDate.minute
    .toString()
    .padStart(2, "0")}:${gregorianDate.second.toString().padStart(2, "0")} UTC`;
};

/**
 * Gets a copy of the array of positive known clock multipliers to associate with the shuttle ring.
 *
 * @returns {Number[]} The array of known clock multipliers associated with the shuttle ring.
 */
AnimationViewModel.prototype.getShuttleRingTicks = function () {
  return this._sortedFilteredPositiveTicks.slice(0);
};

/**
 * Sets the array of positive known clock multipliers to associate with the shuttle ring.
 * These values will have negative equivalents created for them and sets both the minimum
 * and maximum range of values for the shuttle ring as well as the values that are snapped
 * to when a single click is made.  The values need not be in order, as they will be sorted
 * automatically, and duplicate values will be removed.
 *
 * @param {Number[]} positiveTicks The list of known positive clock multipliers to associate with the shuttle ring.
 */
AnimationViewModel.prototype.setShuttleRingTicks = function (positiveTicks) {
  //>>includeStart('debug', pragmas.debug);
  if (!defined(positiveTicks)) {
    throw new DeveloperError("positiveTicks is required.");
  }
  //>>includeEnd('debug');

  let i;
  let len;
  let tick;

  const hash = {};
  const sortedFilteredPositiveTicks = this._sortedFilteredPositiveTicks;
  sortedFilteredPositiveTicks.length = 0;
  for (i = 0, len = positiveTicks.length; i < len; ++i) {
    tick = positiveTicks[i];
    //filter duplicates
    if (!hash.hasOwnProperty(tick)) {
      hash[tick] = true;
      sortedFilteredPositiveTicks.push(tick);
    }
  }
  sortedFilteredPositiveTicks.sort(numberComparator);

  const allTicks = [];
  for (len = sortedFilteredPositiveTicks.length, i = len - 1; i >= 0; --i) {
    tick = sortedFilteredPositiveTicks[i];
    if (tick !== 0) {
      allTicks.push(-tick);
    }
  }
  Array.prototype.push.apply(allTicks, sortedFilteredPositiveTicks);

  this._allShuttleRingTicks = allTicks;
};

Object.defineProperties(AnimationViewModel.prototype, {
  /**
   * Gets a command that decreases the speed of animation.
   * @memberof AnimationViewModel.prototype
   * @type {Command}
   */
  slower: {
    get: function () {
      return this._slower;
    },
  },

  /**
   * Gets a command that increases the speed of animation.
   * @memberof AnimationViewModel.prototype
   * @type {Command}
   */
  faster: {
    get: function () {
      return this._faster;
    },
  },

  /**
   * Gets the clock view model.
   * @memberof AnimationViewModel.prototype
   *
   * @type {ClockViewModel}
   */
  clockViewModel: {
    get: function () {
      return this._clockViewModel;
    },
  },

  /**
   * Gets the pause toggle button view model.
   * @memberof AnimationViewModel.prototype
   *
   * @type {ToggleButtonViewModel}
   */
  pauseViewModel: {
    get: function () {
      return this._pauseViewModel;
    },
  },

  /**
   * Gets the reverse toggle button view model.
   * @memberof AnimationViewModel.prototype
   *
   * @type {ToggleButtonViewModel}
   */
  playReverseViewModel: {
    get: function () {
      return this._playReverseViewModel;
    },
  },

  /**
   * Gets the play toggle button view model.
   * @memberof AnimationViewModel.prototype
   *
   * @type {ToggleButtonViewModel}
   */
  playForwardViewModel: {
    get: function () {
      return this._playForwardViewModel;
    },
  },

  /**
   * Gets the realtime toggle button view model.
   * @memberof AnimationViewModel.prototype
   *
   * @type {ToggleButtonViewModel}
   */
  playRealtimeViewModel: {
    get: function () {
      return this._playRealtimeViewModel;
    },
  },

  /**
   * Gets or sets the function which formats a date for display.
   * @memberof AnimationViewModel.prototype
   *
   * @type {AnimationViewModel.DateFormatter}
   * @default AnimationViewModel.defaultDateFormatter
   */
  dateFormatter: {
    //TODO:@exception {DeveloperError} dateFormatter must be a function.
    get: function () {
      return this._dateFormatter;
    },
    set: function (dateFormatter) {
      //>>includeStart('debug', pragmas.debug);
      if (typeof dateFormatter !== "function") {
        throw new DeveloperError("dateFormatter must be a function");
      }
      //>>includeEnd('debug');

      this._dateFormatter = dateFormatter;
    },
  },

  /**
   * Gets or sets the function which formats a time for display.
   * @memberof AnimationViewModel.prototype
   *
   * @type {AnimationViewModel.TimeFormatter}
   * @default AnimationViewModel.defaultTimeFormatter
   */
  timeFormatter: {
    //TODO:@exception {DeveloperError} timeFormatter must be a function.
    get: function () {
      return this._timeFormatter;
    },
    set: function (timeFormatter) {
      //>>includeStart('debug', pragmas.debug);
      if (typeof timeFormatter !== "function") {
        throw new DeveloperError("timeFormatter must be a function");
      }
      //>>includeEnd('debug');

      this._timeFormatter = timeFormatter;
    },
  },
});

//Currently exposed for tests.
AnimationViewModel._maxShuttleRingAngle = maxShuttleRingAngle;
AnimationViewModel._realtimeShuttleRingAngle = realtimeShuttleRingAngle;

/**
 * A function that formats a date for display.
 * @callback AnimationViewModel.DateFormatter
 *
 * @param {JulianDate} date The date to be formatted
 * @param {AnimationViewModel} viewModel The AnimationViewModel instance requesting formatting.
 * @returns {String} The string representation of the calendar date portion of the provided date.
 */

/**
 * A function that formats a time for display.
 * @callback AnimationViewModel.TimeFormatter
 *
 * @param {JulianDate} date The date to be formatted
 * @param {AnimationViewModel} viewModel The AnimationViewModel instance requesting formatting.
 * @returns {String} The string representation of the time portion of the provided date.
 */
export default AnimationViewModel;
